Skip to content

0.21.0: Karaoke, Performance, stems per mode, GPU export and hardening - #784

Draft
thcp wants to merge 208 commits into
mainfrom
big-release
Draft

thcp wants to merge 208 commits into
mainfrom
big-release

Conversation

@thcp

@thcp thcp commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

What this is

The whole of 0.21.0 on one branch: 208 commits ahead of main. There is no next-release branch this time, so every fix PR went into big-release and this pull request goes straight to main. It stays a draft until I have built and tried it on every desktop platform.

Merging it publishes the Docker :edge image, so I will merge it at release time only. Please merge with a merge commit, not a squash, so the per-feature history and the Closes lines in the commit messages survive.

Read this first: every desktop install does one full download for 0.21.0. The Python dependencies changed (a security update, and Python 3.11 is now the minimum), so the runtime id changes and the in-app updater will not offer this release. The new download carries a fresh runtime. See "Dependencies" below.

What is in it

Modes

  • The Mode menu lists Practice, Advanced, Karaoke and Performance. Advanced, Karaoke and Performance carry an Experimental tag.
  • The Streaming placeholder is gone. Streamer is now called Performance.

Karaoke

  • Lyrics stage with word-by-word highlight, a queue you fill by dragging songs from the library, its own Transport panel, voice and Backing vocals switches, and a Backing track card.

Performance

  • Live camera and microphone or audio-interface input, an "I play" backing track, recording, a review window that plays the take with the song, and a Take Timeline with trim and zoom.
  • Export to MP4 through the server, with Show in folder on the desktop. 720p or 1080p at 24, 30 or 60 fps. No 4K. Upload and streaming are not part of this release.
  • The scene layers, the Templates tab and the settings gear were removed (they did nothing useful yet).
  • GPU video encoder: Auto uses a GPU encoder that passed a test and falls back to the CPU. Settings has a Video encoder choice and a Test encoders table.

Studio

  • Per-lane and master EQ (graphical, Bass, Guitar, Keys), Suggest EQ, a Master drawer with a loudness target and a true-peak limit.
  • Recording tracks, audio and video tracks, takes stored in take folders, and recording over the song.
  • Each mode keeps its own stem choice and saved mix of a song. A Stems popover chooses which stems get a lane, and says why when an Apply does nothing.
  • New Settings layout: sections on the left, a search box, a Library section and a Danger zone.
  • The Add button in Sections is greyed out, with a reason, when there is no free space for a new section. The Bar box in the grid toolbar shows two-digit values in full. At the desktop app's default 1280x820 window the bottom bar no longer scrolls out of sight: whole groups of controls wrap onto a second line instead.
  • The key counter always matches what you hear: it is restored from the song's saved state, including for songs saved by 0.20.

Click track

  • Tick Include click track and choose Export All Stems: the .zip gets a click file next to the stems, with the same length and start, every click on the beat. It follows your beat grid edits, accents and the half or double rate.
  • The click options have a sound selector: Classic (as before), Woodblock, Rimshot, Soft and Cowbell. The live click, the count-in and every export use the sound you pick. Custom samples are not part of this release.
  • Exported clicks are now 3 dB louder than in 0.20, so they match the click you hear in the app (the export used to lower it). If you set the click level by ear against an export, you may want to turn the click volume down a little. A count-in export of an MP3-stored song also comes out in stereo now, where it used to be mono.

Stem storage: a change in default behaviour

  • New songs are now stored as MP3 at 128 kbps by default, about a tenth of the disk space of WAV. Settings > Library > Stem files chooses WAV or MP3, and 128, 192 or 256 kbps. Docker and Unraid get the same choice as STEMDECK_STEM_FORMAT and STEMDECK_STEM_MP3_KBPS.
  • Songs you already have keep their format. Separation and analysis still run on WAV, and the stems are converted at the end, checked, and only then are the WAV files removed. If the conversion fails, the song is kept as WAV.
  • 128 kbps was a deliberate choice. Stems that are summed again carry some stacked coding noise, so 192 or 256 sound closer to the original, and WAV is exact.

Imports, library and playback

  • A Detect lyrics switch, Try again for failed imports, a 12 ms look-ahead compensation on takes.
  • The Library lists songs oldest first, in the order you added them, the same as the Queue. Removing a song from the Queue only stops its extraction: the song stays in the Library with Extract, and nothing goes to the Trash.
  • Queue strings are translated in every language.
  • When Lead + Backing cannot be made, the app now says so, says why in one line, and offers Try again on the same song. The choice is shown as unavailable from the start when a part it needs did not load on that install.
  • Reloading a song no longer leaks audio memory, and changing a stem while playing no longer stops and rewinds.

Desktop

  • FFmpeg on macOS: the app no longer fails first launch when the FFmpeg it downloads has no Vorbis encoder, and it no longer installs an Intel-only FFmpeg where that cannot run. It uses a good FFmpeg you already have (Homebrew on Apple Silicon or Intel, MacPorts, your PATH), never overwrites one you placed in the data folder, and reports a clear next step if none works. OGG export is shown as unavailable when the FFmpeg has no Vorbis encoder; everything else works. This is a product change.
  • Linux CPU package: it shipped the CUDA build of torchvision, which broke the karaoke vocal split. The build is fixed and gated in CI, and existing 0.20 installs repair themselves on their next launch (one internet connection needed).
  • Linux on Wayland with an NVIDIA GPU: the app no longer closes at launch. It turns off WebKit's DMA-BUF renderer by itself where it needs to, falls back after a crash, and STEMDECK_WEBKIT_DMABUF=on|off overrides it.

Hardening

  • A cross-site guard on the server, safer auto-delete (favourites, songs with takes and non-song folders are skipped), a registry that survives a bad record.
  • Moving the library into a folder that already holds another library is refused instead of losing favourites and tags.
  • Settings endpoints answer a clear error, not a 500, when the body is not an object.
  • Delete and Backspace move a song to the Trash only when the song list has the focus.

Dependencies

  • certifi and pillow are updated, together with urllib3, anyio, fsspec and hydra-core, and the matching entries are removed from .trivyignore. The security scan passes without them. requires-python is now >=3.11; every shipped build already used 3.12, and Python 3.10 is no longer supported.
  • The desktop packages install from PyPI at build time rather than from uv.lock; the lock is what CI tests and the scan reads, and it sets the runtime id.

Licence

  • StemDeck is now AGPL-3.0-only. The licence text ships in every package and the image, and About names it.

Fixed since the first version of this description

#771, #772, #773, #774, #775, #778, #780, #781, #783, #785, #788, #789, #791, #793, #794, #797, #798, #799, #803, #810, #812, #814, #824, #827, #832, #833 and #842 are fixed or answered, each checked against its tests. #805 (click file and sounds) is fixed here and closes when this merges. #790 is fixed on the Python side; its Windows part is not run yet (see below). The README is updated; the release notes are written at release time.

How it was checked

  • CI on this branch runs lint, the Python tests, the browser suite in 3 shards, CodeQL, the security scan and the Rust checks on all three platforms, plus a Linux packaging check. On the current tip (ac72e17) all 19 checks pass, including the full browser suite.
  • Latest full local run on macOS: pytest 3402 passed, 16 skipped (the OGG tests skip where the FFmpeg has no Vorbis encoder). Last full run on Windows, 2026-10-11, on an earlier commit: 3178 passed, 5 skipped, 0 failed.
  • MP3 stems decode to exactly the same length as the source in ffmpeg, libsndfile and Chromium (0 samples of lag). A real import on macOS finished as MP3 with the stems at 4 MB instead of 36 MB.
  • The click file has the same length as every other stem, and its 62 clicks land within a millisecond of the 62 beats on a real export.

Measured earlier on my machine, not repeated on the current tip:

  • Closed audio contexts per song reload went from 121 to 0, and the heap from 45 to 8.7 MB.
  • Recorded frame rate at 1080p30 went from 19.8 to 28.6 fps, and at 1080p60 from 22.3 to 47.2 fps (a fake camera).

What is not tested yet

  • Desktop builds on Windows, macOS and Linux with this code (save dialog, Show in folder, camera and microphone prompts, the updater, the Rust shell changes).
  • Apple Silicon without Rosetta and without an FFmpeg: the first launch and its error message. An Intel Mac with FFmpeg in /usr/local/bin.
  • A real Wayland and NVIDIA launch, and a broken 0.20 Linux CPU install repairing itself.
  • MP3 stems and the live click sounds in the Tauri WebKit builds (macOS and Linux) and Safari, and the Docker environment variables in a real container.
  • How each click sound sounds by ear.
  • The Windows kill-on-close job object that makes a quit stop a running export (backendjob.rs, part of Cancelling, deleting or quitting during a video export does not always stop it #790).
  • A real camera. Every sync and performance number uses a fake camera.
  • GPU encoding on Intel, AMD and VAAPI (GPU video encoding is untested on Intel, AMD, Linux and macOS hardware #792). VideoToolbox passes on one Mac.
  • The new Guide entries were checked by the help-mode spec only, not by hovering each control.

Open before the release

Notes for review

  • The Rust shell changed since v0.20.0 (main.rs, new mediaperm.rs, backendjob.rs and dmabuf.rs, the macOS Info.plist and Entitlements.plist, Cargo.toml and Cargo.lock). uv.lock changed, so the runtime id changed (py3.12-585d1341908f343b): existing desktop installs are not offered the in-app update and download the full package once.
  • Translations exist in all ten tables for the strings that shipped. They are not reviewed by native speakers.

Thales added 30 commits October 5, 2026 20:19
Handle the WebView media permission requests in the Tauri shell, and add
the macOS Info.plist usage strings and entitlements that recording needs.
Add the takes API: chunked upload of audio and camera parts, recording
tracks that own takes, alignment from the capture sidecar, and takes as
lanes in the mix export. Exports now pass a limiter at -1 dBFS so summed
lanes cannot clip. A take with a camera recording can be rendered to MP4
over the backing mix.
Add the Record button and recording panel, recording tracks with a voice
or instrument input each, take folders with one lane per take, a live
waveform while recording, and a Recording tab in Settings for the input,
camera and latency test. Stems that were not extracted are hidden from
the mixer. Includes the e2e and node tests for these flows.
Add the camera to the record flow, upload the camera recording with the
take, and a "Make video" action that renders the take over the backing
mix and downloads the MP4. Includes the camera delay nudge, the video
strings in every language, and the e2e and node tests.
The desktop shell grants the mic without saving the grant, so each page load started with nameless devices and a new track's picker read No input found until a track was armed. In the desktop app only, open the mic once per page to read the names when a track picker is shown or a track is added.
A Playwright spec that attaches to a running desktop app over CDP and is skipped unless STEMDECK_DESKTOP_CDP is set, so CI never runs it. Also more origin rule cases and the verified WebView2 behaviour in mediaperm.rs.
WAV and FLAC keep the -1 dBFS sample-peak limiter, bit-identical below it.
MP3, OGG and both MP4 renders now limit at -1.5 dBTP with 4x oversampling,
because the encoder adds peak of its own: measured on real songs, the
AAC in video.mp4 still decoded at +0.4 dBFS with clipped samples. AAC is
written without intensity stereo, which sent single blocks up to 2.7 dB
over the encoder's input. Render cache version 4.
The video block now counts the camera parts sent. When the server holds
fewer, the end of the recording is missing, so the take is kept without
its video instead of with a short one. After one part fails for good the
recorder stops sending the rest: a long take queued hundreds of parts
that were each refused, and Stop waited minutes for them.
The camera file starts with the first frame captured after
MediaRecorder.start(), but its time on the take's clock came from the
recorder's start event, which fires 11 to 61 ms after that frame
depending on the codec and the main thread's load. The recorder now reads
the capture times of the preview's frames and uses the first one after
start(), moved onto the audio stamp clock (perfMsAtFrame0 runs a measured
30 ms behind the capture of the sound). Measured against Chromium's own
A/V recorder with fake devices: placement error -18 to +32 ms before
(21 takes), -4.5 to +2.4 ms after (9 takes, MP4, VP9 and VP8). Engines
without capture times keep the start event, and the sidecar says which.
A 3 s take made a video of the whole song. The video now covers the take
by default, from its count-in to where it stopped, with the backing
playing throughout; "Whole song" stays a choice next to Make video
(remembered per browser, translated). The server takes span "take" or
"song", validated, "take" by default.

When the camera started before the video's start, the render cut the
front and restarted the timestamps at 0, which moved the whole camera up
to one frame early (25 ms measured on a take). Frames are now shifted
first and cut after, so each lands on the nearest frame of the 30 fps
grid. Every lane is cut at the same sample, so the take keeps its place.

A running render can be stopped from the take's row.
Chromium's file-based fake camera and microphone play a flash and a
burst; the take goes through the real recorder, upload and render, and
the flash-to-burst distance in the MP4 must match Chromium's own A/V
recorder over the same tracks. Also checks the offset's sign and size and
that the take's sound is in the video to the sample. Every spec that
opens fake devices now mutes the browser.
The card read a mono 22 kHz buffer cut at 180 s. It now uses ffmpeg
ebur128 on the full stereo file at its own rate, with the true peak.
Songs analysed before are measured again once, when first opened,
tracked by a loudness_version field.
Jobs separated before peaks.json existed, or made by hand, opened on the streaming engine and swapped to the full decode once the empty peaks came back. The first /peaks request now builds the file (one streamed pass per stem, about 1 s for a three minute song), and a first take saved on such a job brings the stems into peaks.json instead of writing the take alone.
A job without peaks started the chunked engine and destroyed it moments later for the full decode. A take started in that moment recorded into the closed context. The engine is now picked once the peaks answer is known; the peaks fetch begins with the job fetch, so this rarely waits.
Record waits for the song's engine to be ready and binds to it. A take whose engine goes away mid-take stops there and keeps what it captured; Stop no longer waits forever on a closed context. A take that never read the song's clock is judged by the audio it holds: over half a second it is kept, placed where the playhead stood, and the panel says so in every language. Before, it was deleted as too short however long it was.
The chunked engine kept every source node it scheduled until pause, seek or destroy, and each node held its five second buffer: memory grew 2.1 MB/s on a six stem song and was freed only at Stop. A node now removes itself when it ends. Measured with CDP on two real songs over three minutes: 2.09 MB/s before, flat after (48 to 61 MB).
Native BiquadFilterNodes in an effects slot between each lane's gain and
its analyser, and a master slot before the destination, shared by both
engines. Smoothed parameters, crossfaded structure changes, bypass that
is bit-exact once its fade has run. EQ saved per song in the mix store,
applied when a song opens, and sent to the export as fx=.
fx_filters computes the Web Audio biquad coefficients in Python and hands
them to ffmpeg's biquad filter in double precision. The mixdown, video.mp4
and Make video renders take a strictly validated fx= value: lane EQ first
in each lane, master EQ before the export limiter. A flat EQ leaves the
graph untouched. Render cache version 5.

Golden vectors from Chromium's own filters hold the browser, the Python
maths and an ffmpeg render to each other.
previewLaneEq plays another EQ on a lane (an A/B compare, a soloed band)
and null goes back to the saved one; nothing reaches the store or the
export. Engine graph comments name the effects slots.
A small EQ tab at the bottom-left of each waveform lane opens the lane's
EQ panel as an extra row right under the lane, in the mixer column and
the waveform column at the same fixed height, so the rows stay aligned.
Open state is kept per song and lane; the tab shows when the EQ is on or
bypassed; the panel row has a saved on/bypass switch. Panel contents load
on first open. The waves column clips without being a scroll container,
so the tab and the panel stick to the visible area at any zoom.
thcp added 11 commits October 21, 2026 23:13
- The export menu reads export_formats from /api/health. With ogg false
  the OGG button stays visible but aria-disabled, with a tooltip and a
  line in the menu giving the reason. A click does nothing, and a
  selected OGG falls back to WAV. Unknown leaves OGG offered.
- New string export.oggUnavailable and an extended help.exportFormat
  Guide sentence in all ten tables (and the pt-PT variant).
- tests/e2e/export-ogg.spec.mjs: disabled with its reason without
  libvorbis, enabled with it, offered when unknown. stubExportFormats in
  helpers.mjs fixes the capability; spec-map.json maps the new module.

Closes #824
The candidate-path test now runs on Linux and Windows too, and the test module only imported env on macOS, so those test targets did not compile.
On Windows ffprobe_beside looks for ffprobe.exe, so the test wrote a file the code never read.
)

Closes #824

## Product change (flagged)
- **libvorbis is no longer required.** Without it StemDeck starts and
works; only OGG export is unavailable.
- The export menu shows OGG disabled with the reason "OGG export is
unavailable: it needs an FFmpeg with Vorbis support."
- A forced OGG request (`mixdown.ogg`, `stems/all.zip?format=ogg`)
answers 422 with an actionable message, no render started.

## What changed
- Shell: `REQUIRED_FFMPEG_ENCODERS` = pcm_s16le, flac, libmp3lame, aac.
libvorbis moves to `OPTIONAL_FFMPEG_ENCODERS`.
- Backend: `app/pipeline/audio_encoders.py` probes `ffmpeg -encoders`
once per binary (cached on `ffmpeg_identity()`).
- `/api/health` gains `export_formats`: `{"wav","mp3","flac","ogg":
bool}` or `null` when unknown. No paths.
- Download errors name the URL and say whether the server was reached
(HTTP 429 and other HTTP errors) or the binary was rejected. curl runs
`--silent`, so no progress meter in the message.
- The macOS setup error ends with the next step: `brew install ffmpeg`,
`ffmpeg-full` for OGG, `STEMDECK_FFMPEG`, Rosetta where relevant.
- Guide: `help.exportFormat` mentions the greyed-out OGG, all ten tables
plus pt-PT.

## New macOS order (Apple Silicon and Intel)
1. `STEMDECK_FFMPEG` (error if it fails). `STEMDECK_FFMPEG_URL` skips
step 3 and downloads only from that URL.
2. Data-folder pair that passes the check and is native.
3. Installed pair that passes, one with libvorbis first:
- Apple Silicon: `/opt/homebrew/bin`,
`/opt/homebrew/opt/ffmpeg-full/bin`, `/usr/local/bin`, `/opt/local/bin`,
PATH. Intel-only binaries skipped.
- Intel: `/usr/local/bin`, `/usr/local/opt/ffmpeg-full/bin`,
`/opt/local/bin`, PATH.
4. Data-folder pair that runs only via Rosetta (kept, not downloaded
over).
5. Download: shaka per-architecture primary (now passes), then evermeet
only on Intel or with Rosetta. Each source staged separately, installed
only after the checks.

## What did not change
- Windows and Linux: a system FFmpeg on PATH still needs libvorbis to
win over the download. Their downloads include libvorbis, so they pass
as before.
- On Windows and Linux a data-folder pair without libvorbis now passes
the check instead of being re-downloaded.
- Linux curl download errors (runtime pack and FFmpeg) get the new
wording. Retries are unchanged.
- No retries added. Pinned URLs and hashes unchanged. `uv.lock`
untouched, runtime id unchanged. No new dependencies.

## Risks
- A user with Homebrew's plain ffmpeg gets no OGG until they install
`ffmpeg-full` and delete the data-folder pair. Setup does not re-run
once a pair passes.
- The installed-pair search runs fixed paths it did not run before
(`/usr/local/bin`, MacPorts).

## Checks (macOS arm64, ffmpeg 9.0.2 without libvorbis)
- `cargo fmt --check`, `cargo test` (179 passed), clippy with the
macos-check flags: pass. `clippy --tests -D warnings`: only the 4
existing errors.
- pytest: 3333 passed, 15 skipped (the 5 OGG renders skip here). ruff,
ruff format, bandit (no medium or high): clean. i18n coverage: clean.
- Playwright: `export-ogg` (new), `export-menu`, `help-mode` pass. Local
full suite: 690 passed, 11 failed. Every failure passes on rerun or also
fails on plain big-release (trash-restore x3, footer-time, karaoke,
grid-value-box). PR CI e2e shards: green.
- PR CI: Linux, Windows and macOS Rust Check all green.
- Real check against an isolated backend: health reports `ogg: false`,
WAV/MP3/FLAC mixdowns and the MP3 zip export, OGG returns 422.

## Not tested
- A real first launch on an Apple Silicon Mac without ffmpeg, an Intel
Mac with ffmpeg in `/usr/local/bin`, and the Rosetta cases. The decision
logic is unit-tested for each.
Checks the counter, every lane's stepper, the audio graph's routing and the saved record agree in Practice, Advanced and Karaoke, after switching back and after a reload. Also a record saved by 0.20, a reopen inside the save debounce, a record read after the engine is up, speed and mutes, and that switching leaves no extra AudioContext or pitch chain.

The transpose spec's reopen test asserted the key went back to 0, which only held because the reopen beat the debounced save.
The counter, the engine and the saved record could disagree when a song was opened again:

- A record saved by 0.20 has each lane's absolute key but no global key, so the counter opened at 0 over lanes still playing transposed. The global key is now read back from the lanes when the record has none (pitchBus.savedGlobalPitch).
- Opening a song inside the 300 ms save debounce read the record from before the change; the late write then stored it behind a screen and an engine at 0. The pending write is flushed before the read.
- The engine's ready handler pushed whatever was in memory when the record had not been read yet. Gains, lane keys, EQs, speed and the global key now wait for the record, and the key controls stay off until it is in.

Closes #781
Closes #781.

## Root cause
On 0.20 the global key was never saved: reopening a song restored each
lane's absolute key from the store (audio still -2) but reset the
counter to 0. big-release saves `__global.pitch`, which fixes a newly
saved song, but three paths still let the counter, the engine and the
saved record disagree:
- A record saved by 0.20 has lane keys and no global key: counter 0 over
lanes at -2 (the reporter's data after upgrading).
- Opening a song inside the 300 ms save debounce read the old record;
the late write then stored the key behind a counter and engine at 0.
- The engine's ready handler pushed in-memory state before the record
was read (desktop reads it over IPC), and nothing re-pushed it.

## What changed
- `pitchBus.savedGlobalPitch`: the global key comes from the record, or
from its lanes when the record has none.
- `loadMixIntoState` flushes the pending write for that record before
reading it.
- `player.js`: gains, lane keys, EQs, speed and global key are pushed
after the record is in; the key controls stay disabled until then.
- New `transpose-switch-song.spec.mjs` (Practice, Advanced, Karaoke;
global and per-lane key; reload; 0.20 record; debounce; late record;
speed and mutes; no AudioContext growth). The audio check reads the
graph routing, not only the engine's reported value.
- `transpose.spec` reopen test expected 0, which only held because of
the debounce race; it now expects the key to stay.

## What did not change
- Performance mode has no key control. Click and count-in stay unpitched
by design. Loop region is still reset when you open a song.
- The slow-speed value (`stemdeck:slow-speed`) is per browser, not per
song: a song saved at 0.75x reopened after the slow value changed plays
0.75x with no speed button lit.
Five presets (Classic, Woodblock, Rimshot, Soft, Cowbell), each with an
accent, a group-start and a beat voice, synthesised procedurally (sines,
seeded noise through a band-pass, the original click's exponential
envelope). The table lives once, as strict JSON inside
static/js/clickSounds.js; app/pipeline/click_sounds.py parses that block
and runs the same formulas in numpy. Classic is the old click: its renders
are byte-identical to big-release.

Presets are level-matched to Classic by EBU R128 loudness of a 16 s 4/4
loop (all -27.8 LUFS, woodblock -27.7), with every voice peak under
0.97 at full click volume.

tests/fixtures/click_sounds.json pins both renderers to one set of
measurements per preset, level and rate (length, peak position and level,
1 ms RMS envelope, spectral centroid, sample sum).
- click_sound on mixdown, video, stems/all.zip and the take render body,
  checked against the preset whitelist (422 otherwise), default classic.
  It is only a table key and a cache-key suffix, never a path or argument.
- stems/all.zip?click=1 adds <song>_click.<format>: rendered from the beat
  grid (user edits included) with the mix's rate, accents, grouping and
  sound, at the stems' rate, channels and exact decoded length (also for
  MP3-stored songs), from sample 0. Gain is what the click contributes to a
  mix export (click_gain, -3 dB per channel from the mono-to-stereo
  conversion), so the zip sums to the mix. Ends in the export limiter for
  the format. No count-in: zip stems are not delayed.
- Without click=1 the zip is unchanged.
A Click sound select in the click track options popover, filled from the
preset table. The choice is a global preference saved with the other click
settings (stemdeck:metronome), reaches the live click and the count-in, and
every export URL (sent only when it is not Classic, so Classic exports keep
their URL and cache entry).

The live click now plays each preset voice from an AudioBuffer rendered once
per sound, level and context rate: one node per click instead of an
oscillator and a gain. Changing the sound re-voices the clicks already
queued at their own times and leaves the one sounding to finish, so the
switch lands on the next beat without a pop, a lost beat or a leaked node.

Include click track now also adds the click to the stems .zip; its tooltip
and Guide sentence say so. New Guide row for the selector. All ten tables
(and pt-PT where the wording differs) translated.

Closes #805
…d selector (#831)

Closes #805

## What changed

- **Click as its own stem.** With Include click track ticked, Export All
Stems adds `<song>_click.<format>` to the .zip (wav, mp3, flac or ogg,
same as the stems). Unticked: the zip is exactly as before.
- **Click sound selector** in the click options popover: Classic
(default, today's sound), Woodblock, Rimshot, Soft, Cowbell. Each has an
accent, a group-start and a beat voice. Global preference, saved with
the other click settings (`stemdeck:metronome`). Used by the live click,
the count-in, mix and region exports, the click stem, the MP4 export and
take renders (`click_sound` in the render body).
- **One preset table** as strict JSON inside `static/js/clickSounds.js`.
`app/pipeline/click_sounds.py` parses that block and runs the same
synthesis in numpy (sines with optional exponential glide, seeded
xorshift32 noise through an RBJ band-pass, the original click's two
exponential ramps).
- **Drift gate**: `tests/fixtures/click_sounds.json` holds per-voice
measurements (length, peak index and level, 1 ms RMS envelope,
power-weighted spectral centroid, sample sum) for every preset, level
and rate (44.1/48 kHz). pytest asserts Python still produces it;
`tests/js/click-sounds.test.mjs` asserts the browser synthesis matches
within tolerance. Same pattern as `click_levels.json`, because the two
CI jobs cannot run both languages.
- Live click: one `AudioBufferSourceNode` per click from a cached voice
buffer (was an oscillator plus a gain). A sound change re-voices queued
clicks at their own times and lets the sounding one finish.
- API: `click_sound` on `mixdown.{ext}`, `video.mp4`, `stems/all.zip`,
take render; whitelisted against the table, 422 otherwise, default
`classic`. Used only as a table key and a cache-key suffix.
`stems/all.zip` also takes `click`, `click_mult`, `click_accent`,
`click_gain`, `click_groups`.

## What did not change

- No custom user sample. Adding one later: a preset entry with `"kind":
"sample"` and a `renderVoice` branch for it on each side (both refuse
any kind but `synth` today); the selector, whitelist and fixture already
read the table.
- Classic is byte-identical to big-release (sha256 fingerprints of four
renders in the tests), and a Classic export keeps its old URL and cache
key.
- No count-in in the zip (see below). Phone UI untouched: it has no
click control.

## Zip layout

```
<song>_drums.wav
<song>_bass.wav
...
<song>_click.wav
```

`click` cannot collide: no stem is named that (checked at import), and
takes are `<label>_<take id>`.

## Gain rule

The click stem carries what the click contributes to a mix export:
`click_gain` and the mix graph's mono-to-stereo conversion (-3 dB per
channel). The zip at unity sums to the mix export within 4 LSB (tested).
Ends in the format's export limiter (bit-transparent below -1 dBFS for
WAV/FLAC, true-peak for MP3/OGG).

## Alignment guarantee

Rendered from the beat grid (user edits included) with the mix's rate,
accent, grouping and sound, from sample 0, at the reference stem's rate
and channels and its exact decoded frame count (also for MP3-stored
songs). Real check, 30 s 120 BPM file, real separation, MP3-stored:
every file 1,323,000 frames / 30.000000 s / 44.1 kHz / 2 ch (wav and mp3
zips); 60 of 60 click onsets on the beats.json times, 0 samples off, for
Classic and Cowbell.

## Count-in

Mix exports only, as today. Zip stems start at the song's first sample
and are not re-encoded or delayed, so there is nowhere before them for a
count-in; adding one only to the click would misalign it.

## Level matching

EBU R128 integrated loudness of a 16 s 4/4 loop at 120 BPM: all presets
-27.8 LUFS (Woodblock -27.7), max voice peak 0.968 at full click volume.
Tested within 0.5 LU and under 0.99 peak.

## Risks

- Listening quality of the four new presets is not verified by ear.
- WebKit (desktop macOS) not run: the live click now relies on
AudioBuffer playback instead of oscillators.
- Found, not fixed: the mix export's click is 3 dB below the live click
(ffmpeg's mono-to-stereo upmix in amix). The stem follows the mix.
- A beat past beats.json `duration` is in the stem but dropped from the
mix's click (pre-existing; durations match on real songs).

## Tests

- pytest: 3402 passed, 16 skipped (OGG without libvorbis here).
- `npm run test:js`, i18n coverage clean, bandit clean, ruff clean,
`uv.lock` unchanged.
- Playwright full suite: 700 passed; failures are the known big-release
ones (trash-restore x3, streamer-media, footer-time, grid-value-box)
plus recording "with a count-in", which passed 3 of 3 on rerun.
- New: `tests/test_click_sounds.py`, `tests/js/click-sounds.test.mjs`,
`tests/e2e/click-sound.spec.mjs`.
Comment thread tests/test_takes_api.py Fixed
thcp added 3 commits October 22, 2026 12:40
A failed split left the song done with one Vocals stem, a bare 500 and
vocal_split "error", so nothing could say what went wrong.

- The worker reports a code with its error line, from the step that
  failed: dependency_missing, model_unavailable, out_of_memory,
  worker_failed. The parent adds timeout and cancelled.
- The job keeps vocal_split_reason (persisted) and to_state carries a
  fixed safe sentence, vocal_split_message. No stderr, path or
  traceback reaches the client.
- A record saved mid-split restores as error/interrupted, not a
  "running" that refused every retry with 409.
- A cancel only applies to the run it was sent to, including one still
  waiting for the pipeline lock.
- Setup warmup and real splits remember the outcome in the data
  folder; /api/health reports vocal_split {available, reason},
  unavailable only for a missing dependency on the same package
  versions, so the #778 repair or an update switches it back on.
Choosing Lead + Backing and getting one Vocals stem used to say nothing.

- A failed split shows a notice: Lead + Backing could not be made, why
  in one line from the server's code, and Try again, which runs the
  same vocal-split endpoint on the same song (no re-import, same queue
  and cancel behaviour). A card in the notification centre keeps it.
  A cancel shows nothing.
- The Stems popover and Karaoke's Separate say why too, including for a
  song whose last split failed before a restart.
- /api/health vocal_split: when a dependency is missing, Lead + Backing
  in the Extract row, the popover's split and Karaoke's Separate are
  shown off with the reason, like OGG without libvorbis. Imports then
  ask for Combined; the saved choice comes back once it is available.
- Strings in all ten tables (plus pt-PT where it differs), a Guide row
  for the new Try again, a Playwright spec and a spec-map rule.

Closes #832
Closes #832

Likely cause on the reporter's install: the Linux CPU package shipped a
CUDA torchvision (#778, fixed in 0.21.0). This PR fixes the second bug
it exposed: a failed split ended with one Vocals stem and no word about
it.

## What changed
- **Failure codes**: the worker reports the failed step; the job keeps
`vocal_split_reason` (persisted) plus a fixed safe
`vocal_split_message`. The failed request's 500 body carries the same.
No stderr, path or traceback reaches the client.
- `dependency_missing`, `model_unavailable`, `out_of_memory`,
`worker_failed`, `timeout`, `cancelled`, `interrupted`
- **Notice**: Lead + Backing could not be made, why in one line, and
**Try again** (same `vocal-split` endpoint, same song, no re-import).
Notification card keeps it. Cancel shows nothing.
- **Stems popover and Karaoke** say why, also after a restart.
- **Startup visibility**: warmup and real splits record the outcome;
`/api/health` reports `vocal_split {available, reason}`. A missing
dependency shows Lead + Backing off with its reason (Extract row,
popover split, Karaoke Separate), like OGG without libvorbis. Tied to
package versions, so the #778 repair or an update turns it back on.
- A record saved mid-split restores as `error/interrupted` instead of
`running` (which 409'd forever). A cancel applies only to its own run,
including one waiting for the pipeline lock.
- i18n in all ten tables, Guide row for Try again, spec-map rule.

## What did not change
- Separation model, Demucs worker, packaging, `uv.lock`.
- Mobile UI keeps its existing toast.
- The split intent is still client-side only: an import queued with Lead
+ Backing loses it if the page reloads before the job finishes (not
fixed here).

## Tests
- pytest: every failure class through the real worker and endpoint,
restart, retry, capability, warmup.
- Playwright `vocal-split-failure.spec.mjs`: notice, Try again, cancel,
popover reason, disabled and enabled option.
- Local: pytest 3440 passed; full Playwright 719 passed, 5 failed
(trash-restore x3, streamer-media, grid-value-box: known on
big-release).

## Risks
- Worker error line is now a JSON object; the parent still reads the old
string form.
- Classification is by phase plus text patterns; a crash with no error
line falls back to text and may misread.
Comment thread tests/test_takes_render.py Fixed
thcp added 4 commits October 22, 2026 17:52
An unmapped file under static/ or app/, and the shared modules that used to be in the map's full list, now add a fixed core set of 17 specs on top of smoke and the mapped specs. The whole suite runs only for a change to how the suite runs. uv.lock and pyproject.toml add the core set. The selector takes --mode full|scoped from the workflow.
…to big-release

e2e.yml holds the steps once. ci.yml calls it for pull requests (scoped unless the base is main) and for pushes to main and releases (full). e2e-full.yml calls it in full mode on every push to big-release, nightly on big-release, and by hand on any ref. A new push to a pull request cancels its older e2e run; nothing else is cancelled. Each shard has a 30 minute timeout.

Closes #835
…g-release (#836)

Closes #835

## What changed

- **PR to `main`, push to `main`, release:** full suite, as before.
- **PR to any other base:** smoke + changed specs + mapped specs. A hub
module (former `full` source entries: `main.js`, `state.js`, `mixer.js`,
`catalog.js`, `index.html`, `app/api/jobs.py`, `stems.py`,
`app/core/**`, ...) or an unmapped file under `static/` or `app/` now
adds a fixed **core set** (17 specs) instead of everything. `uv.lock`
and `pyproject.toml` add core too.
- **Full suite on a non-main PR** only for a change to how the suite
runs: `playwright.config.mjs`, `tests/e2e/helpers*`, `serve.sh`,
`seed.py`, `package*.json`, the three e2e workflows, the selector,
`spec-map.json`, or unmapped code under `tests/e2e/`.
- **New `e2e-full.yml`:** full suite on every push to `big-release`,
nightly (04:17 UTC) on `big-release`, and `workflow_dispatch` with a
`ref` input.
- **New reusable `e2e.yml`** (`workflow_call`, inputs `mode`,
`base-ref`, `checkout-ref`) holds the steps once. Same pinned SHAs,
`permissions: {}`, no secrets.
- **Concurrency:** a new push to a PR cancels that PR's older e2e run.
Pushes, releases, nightly and manual runs are never cancelled.
`timeout-minutes: 30` per shard.

## What did not change

- Application code, specs, `uv.lock`, package files. Three shards,
`--pass-with-no-tests`.
- Check names gain a prefix: `frontend-e2e / frontend-e2e (1)` to `(3)`.
A reusable workflow always prefixes the caller job. No branch protection
or ruleset requires these names today (checked).

## Real diffs, before and after

| Diff | Files | Before | After |
|---|---|---|---|
| #806 (queue.js, catalog.js) | 5 | FULL 105 | 30 (core), includes
queue-progress |
| #820 | 7 | FULL 105 | 59 (core) |
| #821 | 2 | 47 | 47 |
| #823 | 8 | 5 | 5 |
| #828 (uv.lock) | 4 | 5 | 22 (core) |
| #819, #830, #831 | 7 to 19 | FULL 105 | FULL 105 (edit
`spec-map.json`) |
| #829 | 19 | FULL 105 | FULL 105 (edits `helpers.mjs`) |
| #822 | 33 | FULL 105 | FULL 105 (edits `seed.py`) |
| `main...big-release` | 331 | FULL | FULL (PR to main) |

`npx playwright test --list <selected>` matches the selector exactly for
#806 (30 files, 200 tests) and #820 (59 files, 465 tests). Full suite:
724 tests in 105 files. Core + smoke alone: 159 tests in 22 files.

## Trade-off

A non-main PR can pass while a spec outside its core and mapped sets
breaks. The push to `big-release` run catches it on the merge commit,
minutes later.

## Limits

- **The nightly schedule only starts once `e2e-full.yml` reaches
`main`.** GitHub runs scheduled workflows from the default branch, and
the job checks out `big-release` explicitly for that reason.
- Neither concurrency nor the timeout helps a hosted runner GitHub never
assigns (queue time does not count toward the timeout). Fix by hand:
re-run the job, or `gh run cancel <id>` then `gh run rerun <id>
--failed`.
- This PR changes `ci.yml`, so its own CI runs the full suite. Expected.
… app plays it

The click is rendered mono. Live, Web Audio up-mixes it into the stereo
master as L = R = M. In exports, amix left the conversion to
libswresample's default mono-to-stereo matrix, which puts it 3.01 dB
lower on each channel. Every export now converts the click with an
explicit pan=stereo|c0=c0|c1=c0 (CLICK_TO_STEREO): the mix and region
export, the count-in, the stems zip click file, the MP4 export and take
renders. The zip still sums to the mix. The click sound and its timing
are unchanged.

A cached mix with a click misses the cache now (":u" in the click part
of the key), so an upgrade never serves the old level.

Closes #833
Comment thread tests/test_takes_api.py Fixed
thcp added 4 commits October 22, 2026 19:42
Closes #833

## What changed

- The rendered click is mono. Every export now takes it to stereo with
an explicit `pan=stereo|c0=c0|c1=c0` (`CLICK_TO_STEREO`), so each
channel carries the click at its volume, as the app plays it (Web Audio
up-mixes it as L = R = M). Before, amix left the conversion to
libswresample's default matrix: -3.01 dB per channel.
- The stems zip click file uses the same conversion, so the zip still
sums to the mix.
- A cached mix with a click misses the cache (`:u` in the click part of
the key), so nobody is served the old level after upgrading. Mixes
without a click keep their keys.

## Render paths touched

- `app/pipeline/click_render.py:63` `CLICK_TO_STEREO`
- `app/api/stems.py:1956` get_mixdown: mix export (all formats), region
export, count-in
- `app/api/stems.py:2217` get_video_mixdown (MP4)
- `app/api/stems.py:2385` `_render_click_stem` (stems zip click file),
docstring updated
- `app/api/takes.py:1731` take renders (Streamer Mode video)
- `app/api/stems.py:262` mixdown cache key
- Phone UI has no click control, so no phone path.

## What did not change

- The click sound: the mono render, Classic's four sha256 fingerprints
and `tests/fixtures/click_sounds.json` all pass untouched.
`click_levels.json` (accent levels, not dB) untouched.
- Alignment: on a real export, frame counts and onsets are identical to
big-release, and after = before x sqrt(2) within 1.1 LSB.
- The live click.

## Levels (Classic, volume 0.6)

- Export per channel vs live: -3.01 dB before, 0.00 dB after (mix,
region, count-in, zip, MP4, take render).
- Real 20 s song, click only: peak -11.11 to -8.10 dBFS, RMS -37.68 to
-34.67 dBFS.
- Accented click alone at 0.6: peak -7.86 to -4.85 dBFS, still under the
-1 dBFS limiter.

## Risks

- Every click export is 3 dB louder than in 0.20. The limiter now
engages on a click alone from about volume 0.94 (was 1.32). At volume 4
on loud stems: WAV/FLAC peak -1.00 dBFS, MP3 -1.00 dBTP, no sample at
full scale, same as before.
- On an MP3-stored song, a count-in export on big-release came out mono
(stems folded to mono). It is stereo now.

## Tests

- New: the export's per-channel level equals the live buffer times the
volume (mix, region, count-in in WAV and FLAC; zip; MP4; take render).
Each failed on big-release at -3.01 dB.
- New: the loudest click (4, accented) on loud stems stays under the
ceiling in WAV, FLAC, MP3, OGG, in the mix and the zip.
- Updated on purpose: `UPMIX = 1/sqrt(2)` removed from
`tests/test_click_sounds.py` (zip alignment, chosen-sound mix,
zip-sums-to-mix tests now expect unity).

Release note: Exported clicks are now 3 dB louder than in 0.20, so they
match what you hear in the app. If you set the click level by ear
against an export, you may want to turn the click volume down a little.
A slow Azure Ubuntu mirror (29 kB/s) made the browser system-package and
ffmpeg installs run past the 30 minute job limit, so shards were cancelled
before any test ran.

- Pin the e2e runner to ubuntu-24.04 while ubuntu-latest moves to 26.
- scripts/ci/retry.sh: bounded attempts with a per-attempt timeout that
  kills the whole process group, every attempt logged, exit code kept.
  Tested by tests/ci/test_retry_sh.sh in the lint job.
- Browser download and system packages are separate steps, each with a
  10 minute limit; browsers and the downloaded .debs are cached.
- ffmpeg comes from a SHA256-pinned BtbN static build, cached and checked
  on every run, instead of apt; the job fails by name if libvorbis,
  libmp3lame, aac or flac is missing.
- Per-step setup timings and cache hit or miss go to the job summary.

Closes #838
…s as dead

The PR's own CI hit the slow mirror: two shards spent three 170 s attempts
fetching fonts from azure.archive.ubuntu.com at about 40 kB/s. apt only
falls back to the next mirror on an error, so retry.sh now tells the
command its attempt number, and attempts after the first drop the Azure
entry from /etc/apt/apt-mirrors.txt and start on archive.ubuntu.com.

The hang test read a killed child as alive in the lint container, where
PID 1 does not reap orphans; it now reads the state from /proc.

Closes #838
Closes #838

## What changed

- **Runner pinned** to `ubuntu-24.04` for `frontend-e2e` only, with a
comment to revisit after the Ubuntu 26 label migration
(actions/runner-images#14748).
- **`scripts/ci/retry.sh`**: up to 3 attempts, a per-attempt `timeout`
that kills the whole process group, pauses of 10 s then 20 s. Every
attempt is logged with exit code and duration; a retried success leaves
a warning annotation; the last exit code is returned unchanged.
`tests/ci/test_retry_sh.sh` (28 checks) runs in the `lint` job.
- **Browsers**: `npx playwright install chromium` on its own, cached
(`~/.cache/ms-playwright`, keyed on the lockfile's Playwright version),
10 minute step limit.
- **System packages**: `playwright install-deps chromium` on its own,
run as root through the retry wrapper so the timeout can kill apt. apt
gets `Acquire::Retries=3`, http/https timeouts of 20 s and
`DPkg::Lock::Timeout=60` via `/etc/apt/apt.conf.d`, so Playwright's own
apt-get uses them. The downloaded .debs are cached (keyed on Playwright
and image version). Attempts after the first drop
`azure.archive.ubuntu.com` from `/etc/apt/apt-mirrors.txt`, so they
start on `archive.ubuntu.com`: apt falls back only on an error, never on
a trickle. 10 minute step limit.
- **ffmpeg**: no longer apt. A static BtbN build
(`autobuild-2026-09-30-13-08`, ffmpeg 8.1.3, gpl), pinned by SHA256,
cached, and checked against the pin on every run before it is unpacked.
The step prints `ffmpeg -encoders | grep -E "libvorbis|libmp3lame"` and
fails by name if libvorbis, libmp3lame, aac or flac is missing.
- **Job summary**: a table of each setup step's seconds and cache hit,
miss or partial, written with `if: always()`, so a step killed by its
limit shows as "did not finish".

## Why ffmpeg from BtbN

- Static download: GitHub's release CDN, not the Ubuntu mirror that
stalled. BtbN keeps month-end builds for about two years and is already
the desktop app's Windows source. Has libvorbis, libmp3lame and
chromaprint.
- Third-party setup action: rejected. It downloads the binary itself, so
the checksum pin would live in someone else's code and need a review on
every bump. The workflow can pin it directly in a few lines.
- apt with cached .debs: still on the slow mirror whenever any of its 87
packages gets an update, which would be a cache miss.
- shaka-project static builds (the macOS source): no libvorbis.

## Step limits

10 minutes each. A healthy run takes about 20 s for the system packages
and seconds for the rest. The 12-minute installs in #838 were at 29
kB/s; with the .debs cached they should not recur. If they do, failing
at 10 minutes and retrying (apt resumes partial downloads) beats using
up the whole 30 minute job, which stays as the outer limit.

## What did not change

- Job names `frontend-e2e (1)` to `(3)`, the selector, the scoped and
full cadence, `e2e-full.yml`.
- Specs, app code, `uv.lock`, package files.
- Other workflows. The last 200 runs show no slow or failed apt or
download step outside `frontend-e2e` (Linux Rust Check's apt step peaks
at 72 s, the `test` and `linux-installer` apt steps stay under 60 s), so
they are not pinned or changed.

## Evidence

Run 39827053220 (first `e2e-full`, 6c3cf58), attempt 1: shard 1 spent
12m23s in `playwright install --with-deps`, then 16m28s in apt's ffmpeg
until the job limit. Shard 3: 11m38s, then 17m41s. Shard 2: 22 s and 11
s. The log has "Fetched 20.9 MB in 11min 56s (29.2 kB/s)" from
`azure.archive.ubuntu.com`; the Chromium download itself took 9 s. The
image was `ubuntu-24.04` 20261012.334.1. Same pattern in runs
39823430200, 39792892907 and 39827062156.

## Trade-offs

- A retry hides a flaky step for up to 3 attempts. The warning
annotation and the log show it.
- The pin must be revisited after the migration.
- e2e now runs ffmpeg 8.1.3 instead of Ubuntu's 6.1. That matches the
desktop builds, not the Docker image.

## Results on this PR

- First push (3bb1874): the slow mirror came back. One shard in each of
runs 39843695691 and 39843702780 spent three 170 s attempts fetching
fonts from Azure at about 40 kB/s, and failed at 9 minutes instead of
30. That is why the second commit drops Azure on a retry.
- Second push (ca231fb), cold .deb cache, run 39854523178: shard 2's
first attempt timed out at 170 s on Azure, the second dropped Azure and
finished in 13 s. All 3 shards green.
- Warm caches, manual `e2e-full` run 39854515365: browsers 1 s (hit),
system packages 10 to 39 s (hit), ffmpeg 7 to 10 s (hit, checksum and
unpack). All 3 shards green.
- Encoders printed in every shard: libvorbis and libmp3lame present;
`export-ogg` specs pass.

## Not verified

- A step reaching its 10 minute limit. The per-attempt timeout fired for
real; the step limit did not.
- Caches from this branch are not visible to other PRs. They start
helping once the change is on big-release.
Comment thread tests/test_takes_render.py Fixed
thcp added 3 commits October 23, 2026 00:13
With the library open, the tightest one-line controls row is 910px and
the strip had 671px at the desktop app's default 1280x820 window (415px
at the 1024x720 minimum). Past the "tight" level the row scrolled
sideways with no scrollbar showing, cutting Global key mid-label and
hiding the click track behind Export Mix. Same in WKWebView and
Chromium.

footerFit.js gets a fourth level, "rows": whole clusters wrap onto a
second line, packed left, dividers hidden. When Master and Export Mix
beside the strip would push it to more lines, they move to their own
line under it, kept only when that makes the controls row shorter.

The LUFS value no longer loses digits to the Peak qualifier beside it:
the qualifier gives up its width first.

New spec footer-no-clip checks 1280, 1024, 1100 and 1440 for clipped,
overlapping or covered footer controls.

Closes #842
The node model of the strip had no parent, so the new collapse-stack
check threw before any assertion ran. It now has the controls row
around the strip, wraps clusters at the rows level, and checks that
a strip too short for the tight row wraps, that nothing runs past
its edge, and that the tight/rows boundary settles and opens back up.

Closes #842
…#843)

Closes #842

## What changed

- `footerFit.js`: a fourth collapse level, `rows`, after `tight`. Whole
clusters wrap onto a second line (packed left, dividers hidden). Before
it, a row still too wide after `tight` scrolled sideways inside the
strip with no scrollbar.
- At `rows`, Master and Export Mix move to their own line under the
strip (`collapse-stack` on `.footer-row-controls`) only when that makes
the controls row shorter. At 1024 with the library open this is 2
cluster lines plus the actions, instead of 4 cluster lines.
- Analysis cards: `.meta-card-value` no longer shrinks before its
qualifier, so LUFS "-25.2" stops showing as "-25...". The key card keeps
shrinking and wrapping as before.
- New spec `footer-no-clip` at 1280x820, 1024x720, 1100x820, 1440x900:
no footer control clipped by the strip or the viewport, no two
overlapping, nothing under Export Mix, each control is what the hit test
finds at its centre. Plus one test that a wide window undoes `rows`.
Fails on plain big-release at all four sizes.
- `tests/js/footer-fit.test.mjs`: the strip model gets its controls row
and wraps at `rows`; new checks for the tight/rows boundary and
reopening.

## What did not change

- No control's behaviour, markup, label or Guide entry. Tab order is the
DOM order, unchanged.
- The `wrap`, `click` and `tight` levels, and the hysteresis (#633).
- Analysis card labels still ellipsize at 1024 and 1100 (Dynamic Range,
Tempo Stability, the presence labels). That is the existing floor for
those labels, not touched here.

## Strategy

The footer already measures in JS because the room depends on the
library sidebar, not the window (#586), so this extends that ladder
rather than adding a media or container query. Wrapping whole clusters
was the only option that covers the deficit: at 1280 the tight row is
910px for 671px, and no spacing or caption change frees 239px.
Collapsing a group behind a button would move controls behind a click;
wrapping hides nothing and costs one control line of lane height.

## Verified

Same backend, a 20 s sine imported through POST /api/jobs, studio in DAW
mode, library open. Footer controls clipped or overlapping:

| Size | WKWebView before | WKWebView after | Chromium before | Chromium
after |
|---|---|---|---|---|
| 1280x820 | 9 clipped | 0 | 9 clipped | 0 |
| 1024x720 | 16 clipped | 0 | 16 clipped | 0 |
| 1100x820 | 13 clipped | 0 | 13 clipped | 0 |
| 1440x900 | 4 clipped | 0 | 4 clipped | 0 |

Both engines measured identical cluster widths (Inter), so the cause was
width, not WKWebView text metrics.

## Risks

- The footer is taller when it wraps (54px to 118px for the controls at
1280), so the lanes get less height.
- Not verified in WebKitGTK, WebView2 or a packaged app. Only WKWebView
(snapshot harness, not the app) and Chromium.
- A single cluster wider than the whole strip (a window below the 1024
minimum, with the library open) still scrolls.
Comment thread tests/test_takes_api.py
unfinished = await _create(c)
r = await c.delete(f"/api/jobs/{JOB}/takes/{tid}")
assert r.status_code == 200 and r.json() == {"take_id": tid, "status": "deleted"}
assert (await c.delete(f"/api/jobs/{JOB}/takes/{unfinished}")).status_code == 200
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant